iT邦幫忙

2026 iThome 鐵人賽

DAY 10
1

Day 10:混合方案與實戰部署

Day 9 我們加上領域分類與 Jaccard 語義相似度,F1 從 Day 8 的 0.468 回升到 0.524,但仍然遠低於 Day 7 基線的 0.789。

一個很自然的想法是:既然 Day 7 的簡單規則最準、Day 9 的領域過濾能減少誤報,那把兩者「混合」起來,是不是就能拿到兩邊的優點?

今天就來實作這個混合檢測器,並用 Day 7 的評測系統檢驗它。先說結論:混合版 F1 = 0.388,是目前為止最差的版本。這篇文章的重點,是用實際檢測結果找出「為什麼更差」,並決定生產環境到底該用哪個版本。

這一天在系列中的位置

第 1 週:讓 SRS 從黑盒變透明 → Day 1-7 完成架構、分塊、工作流與評測系統,Day 7 基線 F1 = 0.789
第 2 週:檢測優化與生產系統 → Day 8 規則擴展、Day 9 智能分析,今天 Day 10 嘗試混合方案

今日目標

讀完這篇後,你會完成:

  1. 實作三層混合檢測器 HybridConflictDetector(src/hybrid_detector.py)
  2. 用同一套評測資料量化結果,與 Day 7-9 做對比
  3. 逐筆分析誤報與漏報,找出混合方案變差的真正原因
  4. 決定生產環境的配置,並理解置信度門檻的影響

預期結果:平均 Precision 0.667、Recall 0.339、F1 0.388(比 Day 9 的 0.524 下降 26.0%)


問題背景:混合方案想解決什麼

回顧前三天:

版本 做法 問題
Day 7 少量精準規則 規則數量少,覆蓋有限
Day 8 大量擴展規則 誤報大增,Precision 0.722
Day 9 領域分類 + 語義相似度 Recall 只有 0.467

混合方案的假設是:

  • 用高置信度的簡單規則抓明顯衝突(保 Precision)
  • 用只在同領域比較的數值規則補漏報(提 Recall)
  • 最後去重 + 置信度過濾,避免同一對需求被報兩次

這個假設聽起來合理,但它有沒有成立,要交給評測系統判斷。


實現方法:三層檢測架構

constraints(約束清單)
  ↓
層 1:簡單規則  ──────────────┐  多用戶/單用戶、加密/明文、刪除/永久保存
  ↓                         │
層 2:同領域數值衝突 → 去重 ──┤  儲存大小差 > 5 倍、可用性差 > 0.5%
  ↓                         │
層 3:置信度過濾(≥ 0.65)←──┘
  ↓
List[Conflict]
  • 輸入:約束清單 List[Dict],每筆含 id 與 text(格式與 Day 8、Day 9 的 detector 相同)
  • 輸出:List[Conflict],每個衝突帶 confidence 與 detection_method(simple / numeric)
  • 檔案:src/hybrid_detector.py(新增)
  • 下游:tests/test_day10_hybrid.py 把結果轉成 Day 7 的 DetectedConflict 交給 Evaluator 評分

新增後的專案結構:

srs-review-agent/
├── src/
│   ├── evaluation.py          ← Day 7 評測系統(沿用)
│   ├── conflict_detector.py   ← Day 8
│   ├── smart_detector.py      ← Day 9
│   ├── hybrid_detector.py     ← 【新增】Day 10 混合檢測器
│   └── ...
└── tests/
    ├── test_day10_hybrid_unit.py  ← 【新增】單元測試
    └── test_day10_hybrid.py       ← 【新增】三組 SRS 評測

今天只用到 Python 標準庫(dataclasses、enum、re),不需要安裝新的 pip 套件。


代碼示例

1. 資料結構

建立 src/hybrid_detector.py,先定義衝突的資料結構:

from dataclasses import dataclass
from typing import Dict, List, Optional
from enum import Enum
import re


class Severity(str, Enum):
    HIGH = "高"
    MEDIUM = "中"
    LOW = "低"


@dataclass
class Conflict:
    req_id_1: str
    req_id_2: str
    conflict_type: "ConflictType"   # 邏輯矛盾 / 安全性衝突 / 不兼容 ...
    severity: Severity
    description: str
    confidence: float      # 0.0-1.0 的置信度
    detection_method: str  # simple / numeric

和 Day 8 的 Conflict 相比,多了 confidence 與 detection_method 兩個欄位:前者給層 3 過濾用,後者讓我們在評測時看出每一層各貢獻了多少結果。ConflictType 列舉的定義與 src/conflict_detector.py 相同,完整代碼見 src/hybrid_detector.py。

2. 主流程:三層串接

在同一個檔案中加入 HybridConflictDetector:

class HybridConflictDetector:
    def __init__(self):
        self.conflicts: List[Conflict] = []

    def detect_conflicts(self, constraints: List[Dict],
                         min_confidence: float = 0.65) -> List[Conflict]:
        self.conflicts = []

        # 層 1:簡單規則(高精度)
        self.conflicts.extend(self._detect_simple_conflicts(constraints))

        # 層 2:同領域數值衝突,已被層 1 報過的需求對不再重複加入
        for conflict in self._detect_numeric_conflicts(constraints):
            if not self._is_duplicate(conflict, self.conflicts):
                self.conflicts.append(conflict)

        # 層 3:置信度過濾
        return [c for c in self.conflicts if c.confidence >= min_confidence]

層 1 放在最前面,是因為它的置信度最高(0.90-0.95);層 2 的結果只有在「這對需求還沒被層 1 報過」時才會加入。每次呼叫都會重設 self.conflicts,所以同一個 detector 實例可以重複使用。

3. 層 1:簡單規則

_detect_simple_conflicts() 對所有需求兩兩比對,以「加密 vs 明文」為例:

text1 = c1.get("text", "").lower()
text2 = c2.get("text", "").lower()

# 規則 2:加密 vs 明文(雙向檢查)
if (("加密" in text1 or "encrypt" in text1) and
    ("明文" in text2 or "plaintext" in text2)) or \
   (("加密" in text2 or "encrypt" in text2) and
    ("明文" in text1 or "plaintext" in text1)):
    conflicts.append(Conflict(
        req_id_1=c1.get("id", "unknown"),
        req_id_2=c2.get("id", "unknown"),
        conflict_type=ConflictType.SECURITY,
        severity=Severity.HIGH,
        description="加密與明文存儲衝突",
        confidence=0.95,
        detection_method="simple",
    ))

另外兩條規則結構相同:「多用戶 / 單用戶」(置信度 0.95)與「刪除 / 保存」(置信度 0.90)。注意這裡只做關鍵字共現,不看否定詞,這一點稍後會變成誤報的主要來源。

4. 層 2:只在同領域比較數值

_detect_numeric_conflicts() 先呼叫 _are_related_domains() 過濾,只有兩條需求都命中同一組關鍵字(儲存:mb/gb/容量;性能:ms/req/s/並行;可用性:99/%)才繼續比數值:

@staticmethod
def _check_numeric_mismatch(c1, c2, text1, text2) -> Optional[Conflict]:
    nums1 = re.findall(r'(\d+(?:\.\d+)?)', text1)
    nums2 = re.findall(r'(\d+(?:\.\d+)?)', text2)
    if not (nums1 and nums2):
        return None
    v1, v2 = float(nums1[0]), float(nums2[0])

    # 儲存:差異 > 5 倍 → 置信度 0.75
    if ("mb" in text1 or "gb" in text1) and ("mb" in text2 or "gb" in text2):
        if v1 != v2 and max(v1, v2) / min(v1, v2) > 5:
            return Conflict(..., description=f"儲存大小衝突:{v1} vs {v2}",
                            confidence=0.75, detection_method="numeric")

    # 可用性:差異 > 0.5 個百分點 → 置信度 0.70(寫法同上)
    return None

這段是示意片段(... 省略了與層 1 相同的欄位),完整代碼見 src/hybrid_detector.py。要留意兩件事:

  • 只取第一個數字,而且沒有做單位換算(100MB 和 1GB 被當成 100 vs 1 來比)
  • 領域判斷包含「性能」,但數值比較只實作了「儲存」與「可用性」兩種,性能領域的配對會通過過濾、卻永遠不會產生衝突

5. 去重

_is_duplicate() 以「無方向的需求對」判斷重複,(A, B) 與 (B, A) 視為同一對:

@staticmethod
def _is_duplicate(conflict: Conflict, existing: List[Conflict]) -> bool:
    pair = {conflict.req_id_1, conflict.req_id_2}
    return any({e.req_id_1, e.req_id_2} == pair for e in existing)

這是等價的精簡寫法;src/hybrid_detector.py 中用明確的雙向比對實作,行為相同。


驗證結果

單元測試

tests/test_day10_hybrid_unit.py 涵蓋 5 個測試:簡單規則、數值衝突、去重、置信度過濾、三層整合。

cd srs-review-agent
python3 -m pytest tests/test_day10_hybrid_unit.py -v

預期輸出:

tests/test_day10_hybrid_unit.py::test_simple_rules PASSED                [ 20%]
tests/test_day10_hybrid_unit.py::test_numeric_rules PASSED               [ 40%]
tests/test_day10_hybrid_unit.py::test_deduplication PASSED               [ 60%]
tests/test_day10_hybrid_unit.py::test_confidence_filtering PASSED        [ 80%]
tests/test_day10_hybrid_unit.py::test_three_layer_integration PASSED     [100%]

============================== 5 passed in 0.01s ===============================

注意:測試檔開頭用 Path(__file__).resolve().parent.parent 把專案根目錄加入 sys.path,所以不論專案放在哪個路徑都能直接執行,不需要手動設定 PYTHONPATH。

三組 SRS 評測

tests/test_day10_hybrid.py 使用 Day 7 src/evaluation.py 中的 GROUND_TRUTH_TEST_1/2/3(電商、醫療、社交媒體)評分:

python3 tests/test_day10_hybrid.py

輸出重點:

平均指標(3 個測試用例):
  平均精確度:0.667
  平均召回率:0.339
  平均 F1 分數:0.388

評測結果:與 Day 7-9 對比

版本              Precision  Recall   F1      vs Day 7
───────────────────────────────────────────────────────
Day 7 簡單規則    0.850      0.739    0.789   基線 ⭐
Day 8 規則擴展    0.722      0.422    0.468   -40.7%
Day 9 智能分析    0.651      0.467    0.524   -33.6%
Day 10 混合方案   0.667      0.339    0.388   -50.8%

按領域拆開看(F1,Day 7 → Day 10):

測試集 Day 7 Day 10 TP / FP / FN 變化
電商平台 0.750 0.333 1 / 1 / 3 -55.6%
醫療系統 0.727 0.286 1 / 0 / 5 -60.7%
社交媒體 0.889 0.545 3 / 3 / 2 -38.7%

Precision 其實比 Day 9 略高(0.651 → 0.667),真正崩掉的是 Recall(0.467 → 0.339)。


為什麼混合方案更差:逐筆分析

光看 F1 只知道「變差」,要知道「為什麼」,得把每一筆檢測結果和標準答案對照。

原因 1:關鍵字太窄,醫療系統漏掉 5 / 6

醫療系統的標準答案有 6 個衝突,混合版只抓到「病歷加密 vs 明文存儲」這 1 個:

漏掉的衝突 為什麼沒抓到
「多個主治醫生」vs「僅一個醫生可編輯」 規則只認「多用戶 / 單用戶」,不認「多個醫生 / 單醫生」
「僅一個醫生可編輯」vs「單醫生操作設計」 同上
「最多 50 名患者」vs「最多 1 名患者」 沒有命中任何領域關鍵字,層 2 直接跳過
「100 並行用戶」vs「1000 並行用戶」 通過了「性能」領域過濾,但數值比較沒有實作性能類型
「患者數據可公開訪問」vs「HIPAA 認證」 需要領域知識,關鍵字規則無法表達

層 2 原本是設計來補 Recall 的,但在三組資料中總共只產生 1 個結果(社交媒體的 100MB vs 1GB),幾乎沒有發揮作用。

原因 2:不看否定詞,產生語義相反的誤報

所有 4 個誤報都來自層 1 的關鍵字共現:

誤報 問題
「支持明文顯示訂單細節」vs「不需要加密用戶的個人信息」 兩者都偏向不加密,其實不衝突
「帖子內容採用明文存儲」vs「不支持任何加密機制」 同上
「帖子內容採用明文存儲」vs「支持端到端加密」 描述的對象不同(存儲 vs 傳輸),不在標準答案內
「30 天內自動刪除」vs「數據永久保存」 分屬離線消息與帳戶資料,不是同一份資料

「加密」這個關鍵字同時出現在「必須加密」和「不需要加密」中,規則分不出兩者的差別。

原因 3:調高置信度門檻也救不了

直覺上會想:誤報多,那就把 min_confidence 調高。實際掃一次門檻(三組資料平均):

min_confidence Precision Recall F1
0.00 0.667 0.339 0.388
0.65(預設) 0.667 0.339 0.388
0.80 / 0.90 0.633 0.272 0.340
0.95 0.611 0.206 0.290

0.65 和 0.00 結果完全相同,代表預設門檻沒有過濾掉任何東西。門檻調到 0.80 以上,被濾掉的反而是層 2 那個正確的「100MB vs 1GB」數值衝突,而誤報(置信度 0.90-0.95)全部留下。原因是置信度是依規則寫死的,不是依據這筆檢測本身有多可信,所以門檻無法分辨對錯。

去重機制本身運作正常(單元測試通過),但因為層 2 幾乎沒有產出,去重在這份資料上沒有機會發揮。

小結

設計假設 實際情況
層 1 保 Precision 關鍵字共現不看否定詞,產生 4 個誤報
層 2 補 Recall 三組資料只產出 1 個結果
層 3 濾掉低品質結果 置信度按規則寫死,門檻無法分辨對錯

三層各自的弱點疊加,而不是互補。這也說明了關鍵字規則的天花板:它無法理解否定、描述對象和領域知識。


實戰部署:生產環境該用哪個版本

版本選擇

根據評測數據,生產環境不採用 Day 10 混合版。決策依據很單純:在同一組評測資料上,Day 7 的 F1 = 0.789 仍是最高分。

但這裡有一個需要誠實面對的限制:Day 7 的基線分數來自 tests/test_day7_evaluation.py 中的檢測結果,目前專案裡並沒有一個名為「Day 7 detector」、可以直接 import 的類別。可以實際呼叫、並支援 min_confidence 參數的,是今天的 HybridConflictDetector。

所以短期的部署策略是:

SRS 檔案
  ↓
約束提取(Day 5 LangGraph 工作流)
  ↓
HybridConflictDetector.detect_conflicts(min_confidence=0.65)
  ↓
衝突報告(類型、嚴重度、置信度、檢測方法)
  ↓
人工複核(特別是含「不」「無」等否定詞的配對)

呼叫方式

在服務端程式中這樣使用(src/hybrid_detector.py 提供的公開介面):

from src.hybrid_detector import HybridConflictDetector

detector = HybridConflictDetector()
conflicts = detector.detect_conflicts(constraints, min_confidence=0.65)

for c in conflicts:
    print(f"{c.req_id_1} ↔ {c.req_id_2}:{c.description}"
          f"({c.confidence:.0%},{c.detection_method})")
  • constraints 需要是 [{"id": ..., "text": ...}, ...] 格式;缺少欄位時會以 "unknown" / 空字串代替,不會拋出例外
  • min_confidence 維持 0.65:根據上面的門檻掃描,調高只會降低 F1
  • 報告中保留 detection_method,讓審查者知道哪些結果來自數值規則、哪些來自關鍵字規則

從今天學到的權衡

  • 評測系統救了我們:沒有 Day 7 的評測資料,「三層混合」這種聽起來更完整的設計很容易被直接上線
  • 置信度要有依據:寫死在規則上的置信度,無法當作過濾依據
  • Recall 的瓶頸是語義:同義詞(多個醫生 = 多用戶)、否定詞、描述對象,都不是再加幾條關鍵字就能解決的

提交變更

git add src/hybrid_detector.py tests/test_day10_hybrid.py tests/test_day10_hybrid_unit.py
git commit -m "Day 10: 實現三層混合衝突檢測器與評測"

明天預告

今天的逐筆分析指出,漏報與誤報的根源都是「規則看不懂語義」。明天 Day 11 我們換個方向:保留簡單規則做第一層快速篩選,再引入 LLM(本地 Ollama Mistral 7B)驗證有爭議的衝突、補充規則漏掉的隱性衝突,看看能不能突破 Day 7 的 0.789。


上一篇
# Day 9:智能衝突檢測與語義理解
下一篇
Day 11:LLM 輔助檢測與驗證
系列文
解決需求規格書矛盾:用 Claude Code × MCP 實作自律型文檔審查 Agent 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言